WMS 스캐너에서 네트워크 끊김과 중복 스캔 처리

WMS 스캐너에서 네트워크 끊김과 중복 스캔 처리

한눈에 보기

같은 바코드가 두 번 들어왔다는 사실만으로 중복을 판단할 수 없다. 사용자가 실제로 두 번 스캔했을 수도 있고, 한 번 만든 작업이 네트워크 때문에 두 번 전송됐을 수도 있다. 물리 입력마다 불변 scan_id를 만들고 로컬 영속 큐에 먼저 저장한 뒤 서버에 보낸다. 서버는 scan_id를 멱등성 키로 사용해 재전송은 한 번만 반영하되, 별도의 두 스캔은 도메인 규칙에 따라 처리한다.

창고 스캐너는 네트워크가 안정적인 사무실 웹 앱과 사용 조건이 다르다. 작업자가 상품을 들고 이동하고, USB 또는 HID 스캐너로 연속 입력하며, 무선망이 창고 구석에서 끊길 수 있다. 화면 응답이 늦으면 같은 바코드를 다시 찍는 일도 자연스럽다.

이때 단순히 “최근 3초 안에 같은 바코드는 무시”하는 로직을 넣으면 얼핏 문제가 해결된 것처럼 보인다. 그러나 동일 상품을 다른 작업으로 다시 확인해야 하는 경우, 같은 SKU의 개별 수량을 연속 처리하는 경우, 잘못된 존을 바로잡는 경우까지 지워 버릴 수 있다.

반대로 모든 입력을 그대로 서버에 보내면 네트워크 timeout 뒤 재시도로 같은 재고 이동이 두 번 기록될 수 있다.

WMS Scanner를 만들며 이 문제를 생각할 때 가장 먼저 분리해야 했던 것은 물리 입력, 업무 명령, 전송 시도였다. 아래 코드는 실제 저장소 코드를 복사한 것이 아니라 오프라인 처리 구조를 설명하기 위해 만든 예시다.

목차

중복이라는 말에는 세 가지가 섞여 있다

같은 바코드 문자열이 두 번 관측됐다고 해도 원인은 다르다.

종류 예시 원하는 처리
입력 bounce 스캐너가 한 번 읽었는데 키 이벤트가 두 번 들어옴 장치 입력 계층에서 하나로 합침
사용자 재스캔 응답이 없어 같은 상품을 다시 스캔 상황을 알리고 업무 규칙에 따라 처리
전송 재시도 하나의 로컬 작업을 timeout 뒤 다시 POST 서버에서 같은 결과 재생
의도적인 반복 같은 SKU 상품 두 개를 각각 스캔 두 개의 독립 작업으로 처리
상태 변경 스캔 같은 상품을 입고 존에서 검수 존으로 다시 이동 새 업무 명령으로 처리

이들을 모두 barcode + 3초로 제거하면 올바른 작업까지 사라진다. 식별자를 계층별로 나누는 편이 낫다.

raw_input_id
  스캐너 드라이버가 만든 입력 프레임

scan_id
  사용자가 확정한 한 번의 물리 스캔 사건

operation_id
  서버에 반영할 하나의 업무 명령

delivery_attempt
  같은 operation을 전송한 횟수

단순한 이동 작업에서는 scan_idoperation_id를 같은 UUID로 써도 된다. 중요한 것은 재전송할 때 새 ID를 만들지 않는 것이다.

멱등성이 합치는 대상

서버 멱등성은 같은 operation ID의 재전송을 합친다. 같은 barcode의 모든 작업을 합치지 않는다. 바코드는 재고 항목 식별자이고 요청 식별자가 아니다.

스캔을 업무 명령으로 정규화한다

HID 스캐너는 키보드처럼 문자와 Enter를 보낼 수 있다. 이 원시 입력을 바로 HTTP body로 쓰지 않고 현재 작업 컨텍스트와 결합해 명령으로 만든다.

type ScanOperation = {
  operationId: string;
  deviceId: string;
  localSequence: number;
  barcode: string;
  action: "MOVE" | "RECEIVE" | "SHIP" | "LOOKUP";
  targetZone: string | null;
  actorSessionId: string;
  capturedAt: string;
  payloadVersion: 2;
};

targetZone은 스캔 순간의 값을 스냅샷으로 저장한다. 큐를 동기화할 때 현재 UI의 선택 존을 다시 참조하면 오프라인 동안 사용자가 존을 바꾼 경우 과거 스캔까지 새 존으로 전송된다.

function createOperation(
  input: {
    barcode: string;
    action: ScanOperation["action"];
    targetZone: string | null;
  },
  context: DeviceContext,
): ScanOperation {
  const sequence = context.sequence.next();
  return {
    operationId: crypto.randomUUID(),
    deviceId: context.deviceId,
    localSequence: sequence,
    barcode: normalizeBarcode(input.barcode),
    action: input.action,
    targetZone: input.targetZone,
    actorSessionId: context.actorSessionId,
    capturedAt: new Date().toISOString(),
    payloadVersion: 2,
  };
}

바코드 정규화도 범위를 조심한다.

function normalizeBarcode(raw: string): string {
  const trimmed = raw.trim();
  if (trimmed.length < 3 || trimmed.length > 128) {
    throw new Error("INVALID_BARCODE_LENGTH");
  }
  if (/[\u0000-\u001F]/.test(trimmed)) {
    throw new Error("CONTROL_CHARACTER_IN_BARCODE");
  }
  return trimmed.toUpperCase();
}

앞의 0이 의미 있는 운송장 번호라면 숫자로 변환하면 안 된다. 모든 포맷을 대문자로 바꾸는 것도 규격에 따라 위험할 수 있으므로 실제 지원 바코드별 parser를 두는 편이 낫다.

서버 전송 전에 로컬에 기록한다

스캔 직후 HTTP부터 호출하면 앱이 종료되는 순간 입력을 잃을 수 있다.

// 유실 구간이 있는 나쁜 예시
const result = await fetch("/api/wms/scan", {
  method: "POST",
  body: JSON.stringify(operation),
});
history.unshift(result);

요청이 서버에 도달하기 전 프로세스가 죽으면 어디에도 기록이 없다. 올바른 순서는 로컬 큐에 커밋한 뒤 전송을 시도하는 것이다.

sequenceDiagram
    participant S as Scanner
    participant L as Local SQLite
    participant Y as Sync Worker
    participant A as WMS API

    S->>L: operation INSERT
    L-->>S: durable commit
    S-->>S: 대기 상태 UI와 신호음
    S->>Y: sync wake-up
    Y->>A: operationId와 명령 전송
    A-->>Y: 처리 결과
    Y->>L: acknowledged 또는 rejected

SQLite 예시 스키마는 다음처럼 만들 수 있다.

CREATE TABLE scan_outbox (
    operation_id TEXT PRIMARY KEY,
    device_id TEXT NOT NULL,
    local_sequence INTEGER NOT NULL,
    payload_json TEXT NOT NULL,
    payload_hash TEXT NOT NULL,
    state TEXT NOT NULL,
    attempt_count INTEGER NOT NULL DEFAULT 0,
    next_attempt_at TEXT,
    last_error_code TEXT,
    server_result_json TEXT,
    captured_at TEXT NOT NULL,
    acknowledged_at TEXT,
    created_at TEXT NOT NULL,
    CHECK (state IN (
        'pending',
        'sending',
        'acknowledged',
        'rejected',
        'needs_review'
    )),
    UNIQUE (device_id, local_sequence)
);

UI의 최근 내역도 메모리 배열이 아니라 이 테이블에서 읽으면 재시작 뒤 작업 상태를 이어서 보여 줄 수 있다.

로컬 삽입과 화면 피드백 사이에도 의미 차이가 있다. 로컬 DB 커밋 성공은 “작업을 기억했다”는 뜻이지 “서버 재고가 바뀌었다”는 뜻이 아니다.

로컬 큐의 상태 머신

큐 상태를 명시하면 앱 재시작과 오류 복구가 쉬워진다.

stateDiagram-v2
    [*] --> pending: 로컬 저장
    pending --> sending: worker가 lease 획득
    sending --> acknowledged: 서버 성공 또는 replay
    sending --> rejected: 결정적 업무 오류
    sending --> pending: 일시적 오류
    sending --> needs_review: 결과 불명 또는 충돌
    rejected --> pending: 사용자가 새 명령으로 재시도
    needs_review --> pending: 운영자 해결 후
    acknowledged --> [*]

sending 상태는 영구 락이 아니다. 프로세스가 죽으면 남을 수 있으므로 lease 만료나 시작 시 복구 규칙을 둔다.

UPDATE scan_outbox
SET state = 'sending',
    attempt_count = attempt_count + 1,
    next_attempt_at = datetime('now', '+30 seconds')
WHERE operation_id = (
    SELECT operation_id
    FROM scan_outbox
    WHERE state = 'pending'
      AND (next_attempt_at IS NULL OR next_attempt_at <= datetime('now'))
    ORDER BY local_sequence
    LIMIT 1
)
RETURNING *;

SQLite 버전과 동시 접근 방식에 따라 구체 쿼리는 달라질 수 있다. 핵심은 두 worker가 같은 항목을 동시에 소유하지 않게 로컬 트랜잭션 안에서 claim하는 것이다.

앱 시작 시 오래된 sending은 무조건 성공이나 실패로 추정하지 않고 pending으로 돌려 같은 operation ID로 재전송한다. 서버가 멱등적으로 처리하므로 안전하다.

서버는 scan ID로 한 번만 반영한다

서버는 작업 ID를 unique key로 저장하고 재고 이벤트와 같은 트랜잭션에서 처리한다.

CREATE TABLE scan_operations (
    tenant_id uuid NOT NULL,
    operation_id uuid NOT NULL,
    device_id text NOT NULL,
    actor_id uuid NOT NULL,
    payload_hash text NOT NULL,
    status text NOT NULL,
    inventory_event_id uuid,
    response_json jsonb,
    received_at timestamptz NOT NULL DEFAULT now(),
    completed_at timestamptz,
    PRIMARY KEY (tenant_id, operation_id),
    CHECK (status IN ('processing', 'applied', 'rejected'))
);

tenant를 key 범위에 포함해 서로 다른 고객 공간의 ID 충돌을 분리한다. 인증된 tenant와 actor를 사용하고 body의 값을 신뢰하지 않는다.

처리 흐름은 다음과 같다.

BEGIN
  operation_id 선점
  이미 존재하면 payload hash 비교
    같고 완료됨 → 저장된 결과 반환
    같고 처리 중 → 대기 또는 202
    다름 → 409 key reused

  barcode로 재고 항목 잠금
  현재 상태와 action의 전이 규칙 검사
  inventory_event INSERT
  item current_zone 갱신
  operation을 applied로 갱신하고 response 저장
COMMIT

가상의 PostgreSQL 함수 호출 계층은 다음처럼 표현할 수 있다.

async function applyScan(command: ScanOperation, identity: Identity) {
  const canonical = canonicalizeCommand(command);
  const payloadHash = sha256(canonical);

  return database.transaction(async (tx) => {
    const claim = await claimOperation(tx, {
      tenantId: identity.tenantId,
      actorId: identity.actorId,
      command,
      payloadHash,
    });

    if (claim.kind === "replay") return claim.storedResponse;
    if (claim.kind === "conflict") throw new KeyReuseError();

    const item = await tx.inventory.lockByBarcode(command.barcode);
    const transition = decideTransition(item, command);
    const event = await tx.inventoryEvents.insert(transition.event);
    const response = buildResponse(item, transition, event);
    await completeOperation(tx, command.operationId, response);
    return response;
  });
}

위 코드는 구조를 보여 주는 재구성 예시다. 중요한 것은 operation 선점과 재고 이벤트가 같은 트랜잭션 경계에 있다는 점이다.

같은 바코드의 실제 재스캔을 처리한다

서버가 barcode 자체에 unique constraint를 걸면 같은 상품의 이후 이동을 막는다. 대신 재고 항목의 상태 머신이 작업이 유효한지 판단한다.

RECEIVED
  → INSPECTION
  → STORAGE
  → PICKING
  → PACKED
  → SHIPPED

같은 상품을 같은 존으로 다시 이동하는 명령은 제품 정책에 따라 세 가지로 처리할 수 있다.

상황 처리 사용자 메시지
현재 존과 목표 존이 같음 no-op 이벤트 또는 거절 “이미 검수 존에 있습니다”
직전 스캔이 아직 로컬 pending UI에서 확인 요청 “같은 작업이 전송 대기 중입니다”
서로 다른 유효 전이 새 이벤트 적용 “보관 존으로 이동했습니다”
허용되지 않은 역방향 전이 needs_review 또는 거절 “현재 상태에서는 이동할 수 없습니다”

짧은 시간 창은 경고에는 쓸 수 있지만 자동 삭제의 유일 근거로 쓰지 않는다.

function classifyLocalRepeat(
  current: ScanOperation,
  previous: ScanOperation | null,
): "new" | "likely_repeat" | "different_intent" {
  if (!previous || previous.barcode !== current.barcode) return "new";
  if (
    previous.action !== current.action ||
    previous.targetZone !== current.targetZone
  ) {
    return "different_intent";
  }

  const ageMs =
    Date.parse(current.capturedAt) - Date.parse(previous.capturedAt);
  return ageMs < 1500 ? "likely_repeat" : "new";
}

likely_repeat이면 진동이나 경고음을 다르게 내고 “다시 스캔한 것이 맞나요?”를 표시할 수 있다. 사용자가 확인하면 새 operation ID를 가진 진짜 두 번째 명령이다.

스캐너 장치 자체가 한 프레임을 두 번 방출하는 bounce는 더 낮은 계층에서 raw bytes와 Enter framing을 기준으로 짧게 제거한다. 이 처리와 업무 중복 제거를 섞지 않는다.

오프라인 중 바뀐 서버 상태와 충돌한다

오프라인 큐가 완벽해도 서버 상태가 그대로라는 보장은 없다.

09:00 장치 A 오프라인
09:01 장치 A에서 상품 X를 STORAGE로 이동했다고 기록
09:02 장치 B가 온라인에서 상품 X를 SHIPPED로 처리
09:10 장치 A가 연결되어 오래된 MOVE 전송

서버가 마지막 쓰기 승리로 STORAGE를 덮으면 이미 출고된 상품이 창고 안으로 돌아온 것처럼 보인다. 클라이언트 시각만 비교해서도 안 된다.

명령에 스캔 당시 알고 있던 기준 버전을 넣을 수 있다.

{
  "operationId": "op_demo_01",
  "barcode": "ITEM-DEMO-42",
  "action": "MOVE",
  "targetZone": "STORAGE-A",
  "expectedItemVersion": 17,
  "capturedAt": "2026-08-25T09:01:00+09:00"
}

서버는 현재 version이 17일 때만 적용하고, 다르면 충돌 정보를 반환한다.

HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "code": "INVENTORY_STATE_CHANGED",
  "operationId": "op_demo_01",
  "currentState": "SHIPPED",
  "currentVersion": 18,
  "resolution": "manual_review"
}

모든 작업에 optimistic version이 필요한 것은 아니다. 현재 어느 존이든 지정 존으로 이동시키는 명령이 의도라면 최신 상태에 적용할 수도 있다. 그러나 출고 완료처럼 되돌리면 안 되는 상태는 전이 규칙으로 막아야 한다.

오프라인 시간 순서가 서버 진실은 아니다

장치 시계는 틀릴 수 있고 여러 장치 사이 순서를 정하지 못한다. capturedAt은 감사와 UX 정보로 사용하되, 서버 상태 전이의 최종 순서는 서버가 할당한 event sequence와 version으로 결정한다.

동기화 순서와 배치 크기

큐를 무조건 병렬 전송하면 같은 상품에 대한 순차 명령의 순서가 뒤집힐 수 있다.

op-101: 상품 X → INSPECTION
op-102: 상품 X → STORAGE

병렬 전송 결과
op-102 먼저 도착 → 현재 상태에서는 STORAGE 불가
op-101 나중 도착 → INSPECTION 적용

가장 단순한 방법은 장치의 local_sequence 순서로 하나씩 보내는 것이다. 처리량이 필요하면 서로 독립적인 key끼리 병렬화한다.

function partitionForSync(operations: ScanOperation[]) {
  const byBarcode = new Map<string, ScanOperation[]>();

  for (const operation of operations) {
    const bucket = byBarcode.get(operation.barcode) ?? [];
    bucket.push(operation);
    byBarcode.set(operation.barcode, bucket);
  }

  for (const bucket of byBarcode.values()) {
    bucket.sort((a, b) => a.localSequence - b.localSequence);
  }
  return byBarcode;
}

각 barcode bucket 내부는 순차, 서로 다른 bucket은 제한된 동시성으로 보낸다. 다만 팔레트 전체 완료처럼 여러 상품 사이 순서가 의미 있다면 work_session_idbatch_id 수준으로 직렬화해야 한다.

배치 API는 네트워크 효율을 높이지만 전체 성공/실패만 반환하면 재시도가 어렵다. 항목별 결과를 돌려준다.

{
  "batchId": "sync_demo_07",
  "results": [
    {
      "operationId": "op_demo_01",
      "status": "applied",
      "eventId": "event_demo_a"
    },
    {
      "operationId": "op_demo_02",
      "status": "rejected",
      "code": "INVALID_TRANSITION"
    }
  ]
}

HTTP 응답 자체를 못 받으면 배치 전체를 같은 operation ID들로 다시 보낸다. 서버의 항목별 멱등성이 이미 적용된 항목은 재생한다.

성공 응답과 오류를 로컬 상태에 반영한다

동기화 오류를 모두 retry하면 영원히 실패하는 poison item이 뒤 작업을 막는다.

결과 로컬 상태 재시도
2xx applied/replayed acknowledged 없음
네트워크 timeout pending backoff 후
429, 503 pending Retry-After와 jitter
400 형식 오류 rejected 코드 수정 또는 새 명령
401 세션 만료 큐 유지, 로그인 필요 인증 복구 후
409 상태 충돌 needs_review 자동 반복 금지
같은 ID·다른 payload needs_review 데이터 손상 조사

지수 backoff에는 jitter를 넣어 여러 스캐너가 네트워크 복구 순간 동시에 몰리지 않게 한다.

function nextRetryDelayMs(attempt: number): number {
  const capped = Math.min(30_000, 500 * 2 ** Math.min(attempt, 6));
  const jitter = 0.5 + Math.random();
  return Math.round(capped * jitter);
}

결정적 오류는 큐에서 삭제하지 않고 작업자에게 보여 준다. 사용자가 현재 서버 상태를 확인하고 새 명령을 만들거나 관리자가 해결할 수 있어야 한다.

payload hash가 달라진 멱등성 충돌은 일반 업무 오류가 아니다. 동일 operation ID의 로컬 레코드가 수정됐거나 장치 ID 생성기가 깨졌을 가능성이 있어 경고 수준을 높인다.

사용자에게 임시 성공을 어떻게 보여 줄까

현장에서는 스캔 후 즉각적인 소리와 색상 피드백이 중요하다. 하지만 오프라인 저장 성공을 서버 반영 성공처럼 초록색으로 표시하면 작업자가 실제 재고가 갱신됐다고 오해한다.

상태를 시각과 소리로 구분한다.

상태 화면 피드백
로컬 저장 완료, 전송 대기 노란색 “대기 3건” 짧은 대기음
서버 반영 완료 초록색 “이동 완료” 성공음
중복 가능 입력 주황색 확인창 별도 경고음
상태 충돌 빨간색 “확인 필요” 오류음과 계속 표시
네트워크 끊김 상단 offline 배지 작업은 계속 가능함을 안내

스캔 속도가 빠르면 각 항목이 곧바로 성공으로 바뀌는 UI가 오히려 산만할 수 있다. 현재 네트워크 상태와 pending / needs_review 개수를 고정 영역에 표시하고 최근 기록은 상태 아이콘으로 구분한다.

OFFLINE · 전송 대기 12 · 확인 필요 1

09:31 ITEM-043 → STORAGE-A    대기
09:31 ITEM-042 → STORAGE-A    대기
09:29 ITEM-041 → STORAGE-A    완료

앱 종료 시 pending이 남아도 데이터가 로컬에 안전하게 저장됐음을 알려 준다. 사용자가 강제로 로그아웃하거나 장치를 교체할 때는 미동기화 작업을 명시적으로 경고한다.

장치 시간과 사용자 세션을 믿는 범위

오프라인 작업에는 “누가 실행했는가”가 남아야 하지만 만료된 로그인 토큰을 무기한 허용할 수는 없다.

분리할 값은 다음과 같다.

오프라인 허용 시간은 위험에 따라 정한다. 단순 정보 조회와 출고 완료는 같은 정책일 필요가 없다.

offline_policy:
  MOVE:
    max_session_age: 8h
    requires_online_confirmation: false
  SHIP:
    max_session_age: 30m
    requires_online_confirmation: true
  LOOKUP:
    max_session_age: 24h
    requires_online_confirmation: false

이 YAML도 예시 정책이다. 서버가 동기화 시점에 현재 사용자 권한을 다시 검사할지, 스캔 당시의 서명된 권한 snapshot을 인정할지 결정해야 한다. 권한이 철회된 사용자의 오래된 오프라인 출고 명령을 자동 반영하는 것은 위험할 수 있다.

장치 시각이 미래나 과거로 크게 벗어나면 이벤트를 거부하기보다 clock_skew_detected로 표시해 검토할 수 있다. 순서 자체는 local sequence와 서버 version을 사용한다.

큐 손상과 디스크 장애에 대비한다

로컬 큐도 실패할 수 있다.

로컬 저장 실패 상태에서 스캔을 정상 접수했다고 표시하면 안 된다.

async function acceptPhysicalScan(raw: string): Promise<void> {
  const operation = createOperation(parseScan(raw), deviceContext);

  try {
    await outbox.transaction(async (tx) => {
      await tx.insert(operation);
      await tx.appendAudit("scan_captured", operation.operationId);
    });
  } catch (error) {
    feedback.playCriticalError();
    ui.blockScanning("작업을 저장할 수 없습니다. 디스크 상태를 확인하세요.");
    throw error;
  }

  feedback.playQueued();
  syncWorker.wake();
}

DB migration은 기존 pending 항목을 읽을 수 있게 forward-compatible하게 만든다. payload JSON에 payloadVersion을 둔 이유도 여기에 있다. 앱 업데이트 뒤 이전 버전 payload를 새 형식으로 변환하거나 호환 handler로 처리한다.

중요 작업이라면 큐 백업이나 관리자 내보내기를 제공할 수 있다. 다만 payload에 개인정보와 물류 정보가 있을 수 있으므로 암호화, 파일 권한, 보존 기간을 적용한다.

테스트해야 하는 현장 시나리오

네트워크 on/off 버튼만 눌러 보는 것으로는 부족하다.

응답 유실과 재전송

서버가 명령을 커밋한 직후 연결을 끊는다. 로컬 항목은 pending으로 돌아가고, 다음 전송이 같은 서버 결과를 받아 acknowledged가 되는지 확인한다. 재고 이벤트는 하나여야 한다.

test("lost response is replayed without a second movement", async () => {
  const operation = exampleMove("op-test-001");

  await server.applyAndDropConnection(operation);
  await expect(syncOnce()).rejects.toThrow("CONNECTION_LOST");

  await syncOnce();

  expect(await server.countOperations(operation.operationId)).toBe(1);
  expect(await server.countInventoryEvents(operation.operationId)).toBe(1);
  expect(await localState(operation.operationId)).toBe("acknowledged");
});

같은 바코드, 다른 operation ID

사용자가 실제로 두 번 스캔한다. 서버가 단순 중복으로 지우지 않고 두 번째 명령을 현재 상태 전이 규칙에 따라 no-op, 적용 또는 거절하는지 확인한다.

앱 종료 지점

각 지점에서 프로세스를 종료하고 재시작해 작업이 유실되거나 두 번 반영되지 않는지 본다.

순서 역전

같은 상품의 두 명령에 서버 지연을 다르게 주고 local sequence가 보존되는지 확인한다. 다른 상품은 제한적으로 병렬 처리되는지도 본다.

네트워크 flapping

online/offline이 짧게 반복될 때 worker가 여러 개 시작되지 않고 backoff가 초기화되어 요청 폭풍이 생기지 않는지 확인한다.

여러 장치 충돌

장치 A와 B가 같은 상품을 서로 다른 존으로 이동한다. 서버 version과 상태 머신에 따라 하나가 충돌하고 자동으로 마지막 쓰기 승리가 되지 않는지 본다.

디스크 부족

로컬 DB insert를 실패시켜 성공음을 내지 않고 스캔을 차단하는지 확인한다. 이미 저장된 pending은 보존해야 한다.

상태뿐 아니라 원장을 확인한다

최종 current_zone만 같으면 중복 이벤트가 숨어 있을 수 있다. operation, inventory event, item version, 작업자 감사 로그 수가 모두 불변식에 맞는지 검사한다.

운영 지표와 추적 정보

오프라인 기능은 평소에는 조용하지만 장애가 생기면 현장 생산성에 직접 영향을 준다.

수집할 지표는 다음과 같다.

구조화 로그에는 추적 가능한 식별자를 남기되 실제 바코드는 마스킹하거나 해시한다.

{
  "event": "scan_sync_result",
  "operationId": "op_demo_01",
  "deviceId": "device-demo-12",
  "barcodeHash": "demo:84ad...",
  "action": "MOVE",
  "targetZone": "STORAGE-A",
  "attempt": 3,
  "result": "replayed",
  "queueAgeMs": 18342,
  "serverEventId": "event_demo_a"
}

대시보드의 “오프라인 장치 수”보다 오래된 pending과 review 개수가 더 직접적인 위험 신호다. 장치가 온라인으로 표시되어도 인증 오류나 poison item 때문에 큐가 멈출 수 있다.

알림은 일시적 offline 자체보다 다음 조건에 둔다.

구현하면서 놓치기 쉬운 경계

UI 버튼 비활성화는 정확성 보장이 아니다

입력 debounce와 버튼 잠금은 작업자 경험을 개선하지만 프로세스 재시작과 네트워크 재시도를 막지 못한다. 서버 멱등성이 최종 방어선이다.

최근 바코드 캐시는 영속 멱등성 저장소가 아니다

메모리 Set은 재시작하면 사라지고 여러 서버 인스턴스가 공유하지 않는다. TTL이 지나면 동일 요청도 다시 실행된다. 업무 트랜잭션과 연결된 unique key가 필요하다.

성공 응답 전에 로컬 항목을 지우지 않는다

요청을 보내자마자 dequeue하면 응답 유실 때 결과를 확인할 방법이 없다. acknowledged 상태를 영속한 뒤 보존 기간에 따라 정리한다.

실패 항목 하나가 전체 큐를 영원히 막지 않게 한다

같은 상품의 후속 작업은 순서 의존성이 있으므로 멈춰야 할 수 있지만, 관련 없는 상품까지 모두 막을 필요는 없다. dependency key별로 격리한다.

조회와 변경을 같은 스캔처럼 다루지 않는다

LOOKUP은 네트워크 실패 시 캐시로 표시할 수 있지만 MOVE는 상태 변경 명령이다. 화면 모드와 피드백을 분명히 해 작업자가 정보 조회를 이동 완료로 오해하지 않게 한다.

출력 작업도 멱등성이 필요하다

송장 발급과 프린터 출력은 재고 이동과 다른 부수 효과다. 동일 송장 번호를 재발급하는 것과 같은 문서를 다시 인쇄하는 것을 분리하고 별도 operation ID와 사용자 확인을 둔다.

큐 암호화가 키 관리까지 해결하지 않는다

장치 로컬 DB를 암호화하더라도 키가 같은 파일 옆에 있으면 보호가 약하다. OS key store, 장치 등록 해제, 원격 폐기 정책을 함께 설계한다.

정리

WMS 스캐너에서 오프라인과 중복 처리를 안전하게 만들려면 “같은 바코드를 몇 초 동안 무시한다”는 규칙을 넘어야 한다.

설계 원칙을 정리하면 다음과 같다.

  1. raw 입력, 물리 스캔, 업무 명령, 전송 시도를 구분한다.
  2. 각 업무 명령에 장치가 불변 operation ID와 local sequence를 부여한다.
  3. 스캔 순간의 action, target zone, 사용자 세션을 payload에 스냅샷으로 저장한다.
  4. 네트워크 요청보다 먼저 로컬 영속 큐에 커밋한다.
  5. 서버는 operation ID와 payload hash로 재전송을 한 번만 반영한다.
  6. 바코드 자체를 멱등성 키로 쓰지 않고 실제 재스캔은 상태 전이 규칙으로 판단한다.
  7. 오프라인 중 서버 상태가 바뀌면 item version과 허용 전이로 충돌을 감지한다.
  8. 같은 상품의 명령은 순서를 보존하고 독립 상품만 제한적으로 병렬화한다.
  9. pending, acknowledged, rejected, needs-review를 현장 UI에서 분명히 구분한다.
  10. 응답 유실, 앱 강제 종료, 네트워크 flapping, 다중 장치 충돌을 장애 주입으로 검증한다.

신뢰할 수 있는 오프라인 스캐너는 모든 입력을 성공했다고 낙관하는 프로그램이 아니다. 입력을 먼저 잃지 않게 기록하고, 서버가 확인한 사실과 아직 확인하지 못한 의도를 끝까지 구분하는 프로그램이다.

관련 노트